// ReSharper disable once CheckNamespace
namespace Fluent;

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Threading;
using Fluent.Internal;
using Windows.Win32;

/// <summary>
/// Handles Alt, F10 and so on
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public class KeyTipService
{
    #region Fields

    private ScopeGuard? windowPreviewKeyDownScopeGuard;

    // Host element, usually this is Ribbon
    private readonly Ribbon ribbon;

    // Timer to show KeyTips with delay
    private readonly DispatcherTimer timer;

    // Is KeyTips Actived now
    private KeyTipAdorner? activeAdornerChain;
    // This element must be remembered to restore focus
    private FocusWrapper? backUpFocusedControl;

    // Window where we attached
    private Window? window;

    // Whether we attached to window
    private bool attached;

    // Attached HWND source
    private HwndSource? attachedHwndSource;

    private string? currentUserInput;

    /// <summary>
    /// Checks if any keytips are visible.
    /// </summary>
    public bool AreAnyKeyTipsVisible
    {
        get
        {
            if (this.activeAdornerChain is not null)
            {
                return this.activeAdornerChain.AreAnyKeyTipsVisible;
            }

            return false;
        }
    }

    private static readonly Key[] modifierKeys =
    {
        Key.LeftShift,
        Key.RightShift,
        Key.LeftCtrl,
        Key.RightCtrl,
        Key.LeftAlt,
        Key.RightAlt,
    };

    /// <summary>
    /// The default keys used to activate key tips.
    /// </summary>
    public static IList<Key> DefaultKeyTipKeys =>
        new List<Key>
        {
            Key.LeftAlt,
            Key.RightAlt,
            Key.F10
        };

    /// <summary>
    /// List of key tip activation keys.
    /// </summary>
    public IList<Key> KeyTipKeys { get; } = DefaultKeyTipKeys;

    #endregion

    #region Initialization

    /// <summary>
    /// Default constrctor
    /// </summary>
    /// <param name="ribbon">Host element</param>
    public KeyTipService(Ribbon ribbon)
    {
        this.ribbon = ribbon ?? throw new ArgumentNullException(nameof(ribbon));

        // Initialize timer
        this.timer = new DispatcherTimer(TimeSpan.FromSeconds(0.7), DispatcherPriority.SystemIdle, this.OnDelayedShow, Dispatcher.CurrentDispatcher);
        this.timer.Stop();
    }

    #endregion

    /// <summary>
    /// Attaches self
    /// </summary>
    public void Attach()
    {
        if (this.attached)
        {
            return;
        }

        this.attached = true;

        // KeyTip service must not work in design mode
        if (DesignerProperties.GetIsInDesignMode(this.ribbon))
        {
            return;
        }

        this.window = Window.GetWindow(this.ribbon);
        if (this.window is null)
        {
            return;
        }

        this.window.PreviewKeyDown += this.OnWindowPreviewKeyDown;
        this.window.KeyUp += this.OnWindowKeyUp;

        // Hookup non client area messages
        this.attachedHwndSource = (HwndSource?)PresentationSource.FromVisual(this.window);
        this.attachedHwndSource?.AddHook(this.WindowProc);
    }

    /// <summary>
    /// Detachs self
    /// </summary>
    public void Detach()
    {
        if (this.attached == false)
        {
            return;
        }

        this.attached = false;

        // prevent delay show
        this.timer.Stop();

        if (this.window is not null)
        {
            this.window.PreviewKeyDown -= this.OnWindowPreviewKeyDown;
            this.window.KeyUp -= this.OnWindowKeyUp;

            this.window = null;
        }

        // Unhook non client area messages
        this.attachedHwndSource?.RemoveHook(this.WindowProc);
    }

    // Window's messages hook up
    private IntPtr WindowProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
    {
        var message = (uint)msg;

        // We must terminate the keytip's adorner chain if:
        if (message == PInvoke.WM_NCACTIVATE // mouse clicks in non client area
            || (message is PInvoke.WM_ACTIVATE && wParam == IntPtr.Zero) // the window is deactivated
            || message is >= PInvoke.WM_NCLBUTTONDOWN and <= PInvoke.WM_NCXBUTTONDBLCLK // mouse click (non client area)
            || message is >= PInvoke.WM_LBUTTONDOWN and <= PInvoke.WM_MBUTTONDBLCLK) // mouse click
        {
            if (this.activeAdornerChain?.IsAdornerChainAlive == true)
            {
                this.Terminate();
            }
        }

        // Fix for #632.
        // Yes this looks awkward, calling the PopupService here, but the alternative would be to let the PopupService know about windows.
        if (ShouldDismissAllPopups(message, wParam))
        {
            PopupService.RaiseDismissPopupEvent(this.ribbon, DismissPopupMode.Always, DismissPopupReason.ApplicationLostFocus);
            PopupService.RaiseDismissPopupEvent(Mouse.Captured, DismissPopupMode.Always, DismissPopupReason.ApplicationLostFocus);
            PopupService.RaiseDismissPopupEvent(Keyboard.FocusedElement, DismissPopupMode.Always, DismissPopupReason.ApplicationLostFocus);
        }

        return IntPtr.Zero;

        static bool ShouldDismissAllPopups(uint message, IntPtr wParam)
        {
            return message switch
            {
                PInvoke.WM_ACTIVATE when wParam == IntPtr.Zero => true, // the window is deactivated
                PInvoke.WM_SIZE => true, // the window state changed (minimize etc.)
                PInvoke.WM_DESTROY => true, // the window is closed
                PInvoke.WM_QUIT => true, // the application is exiting
                _ => false
            };
        }
    }

    private void OnWindowPreviewKeyDown(object? sender, KeyEventArgs e)
    {
        if (this.ribbon.IsKeyTipHandlingEnabled == false)
        {
            return;
        }

        if (this.windowPreviewKeyDownScopeGuard?.IsActive == true)
        {
            System.Media.SystemSounds.Beep.Play();
            return;
        }

        using var scopeGuard = new ScopeGuard().Start();
        this.windowPreviewKeyDownScopeGuard = scopeGuard;

        if (e.IsRepeat
            || e.Handled)
        {
            return;
        }

        if (this.ribbon.IsCollapsed
            || this.ribbon.IsEnabled == false
            || this.window is null
            || this.window.IsActive == false)
        {
            return;
        }

        // Keytips should be cancelled if Alt+Num0 is pressed #241.
        // This allows entering special keys via numpad.
        if (e.KeyboardDevice.Modifiers == ModifierKeys.Alt
            && e.SystemKey >= Key.NumPad0
            && e.SystemKey <= Key.NumPad9)
        {
            this.Terminate();
            return;
        }

        if (this.IsShowOrHideKey(e))
        {
            if (this.activeAdornerChain is null
                || this.activeAdornerChain.IsAdornerChainAlive == false
                || this.activeAdornerChain.AreAnyKeyTipsVisible == false)
            {
                this.ShowDelayed();
            }
            else
            {
                this.Terminate();
            }
        }
        else if (e.Key == Key.Escape
                 && this.activeAdornerChain is not null)
        {
            this.activeAdornerChain.ActiveKeyTipAdorner.Back();
            this.ClearUserInput();
            e.Handled = true;
        }
        else if ((e.Key != Key.System && this.activeAdornerChain is null)
                 || e.SystemKey == Key.Escape
                 || (e.KeyboardDevice.Modifiers != ModifierKeys.Alt && this.activeAdornerChain is null))
        {
            return;
        }
        else
        {
            var actualKey = e.Key == Key.System ? e.SystemKey : e.Key;
            // we need to get the real string input for the key because of keys like ä,ö,ü #258
            var key = KeyEventUtility.GetStringFromKey(actualKey);
            var isKeyRealInput = string.IsNullOrEmpty(key) == false
                                 && key != "\t";

            // Don't do anything and let WPF handle the rest
            if (isKeyRealInput == false)
            {
                // This block is a "temporary" fix for keyboard navigation not matching the office behavior.
                // If someone finds a way to implement it properly, here is your starting point.
                // In office: If you navigate by keyboard (in menus) and keytips are shown they are shown or hidden based on the menu you are in.
                // Implementing navigation the way office does would require complex focus/state tracking etc. so i decided to just terminate keytips and not restore focus.
                {
                    this.backUpFocusedControl = null;
                    this.Terminate();
                }

                return;
            }

            var shownImmediately = false;

            // Should we show the keytips and immediately react to key?
            if (this.activeAdornerChain is null
                || this.activeAdornerChain.IsAdornerChainAlive == false
                || this.activeAdornerChain.AreAnyKeyTipsVisible == false)
            {
                this.ShowImmediatly();
                shownImmediately = true;
            }

            if (this.activeAdornerChain is null)
            {
                return;
            }

            var previousInput = this.currentUserInput;
            this.currentUserInput += key;

            if (this.activeAdornerChain.ActiveKeyTipAdorner.ContainsKeyTipStartingWith(this.currentUserInput) == false)
            {
                // Handles access-keys #258
                if (shownImmediately)
                {
                    this.Terminate();
                    return;
                }

                // KeyTipService should dismiss keytips if the first key does not match any keytips #908
                if (this.activeAdornerChain.AdornedElement is Ribbon)
                {
                    this.Terminate();
                    return;
                }

                // If no key tips match the current input, continue with the previously entered and still correct keys.
                this.currentUserInput = previousInput;
                System.Media.SystemSounds.Beep.Play();
                e.Handled = true;
                return;
            }

            if (this.activeAdornerChain.ActiveKeyTipAdorner.Forward(this.currentUserInput, true))
            {
                this.ClearUserInput();
                e.Handled = true;
                return;
            }

            this.activeAdornerChain.ActiveKeyTipAdorner.FilterKeyTips(this.currentUserInput);
            e.Handled = true;
        }
    }

    private void OnWindowKeyUp(object sender, KeyEventArgs e)
    {
        if (this.ribbon.IsKeyTipHandlingEnabled == false)
        {
            return;
        }

        if (this.ribbon.IsCollapsed
            || this.ribbon.IsEnabled == false
            || this.window is null
            || this.window.IsActive == false)
        {
            this.Terminate();
            return;
        }

        if (this.IsShowOrHideKey(e))
        {
            this.ClearUserInput();

            if (this.timer.IsEnabled)
            {
                this.ShowImmediatly();
            }

            if (this.activeAdornerChain is not null)
            {
                e.Handled = true;
            }
        }
        else
        {
            this.timer.Stop();
        }
    }

    private bool IsShowOrHideKey(KeyEventArgs e)
    {
        var realKey = e.Key == Key.System
            ? e.SystemKey
            : e.Key;

        // Shift + F10 is meant to open the context menu. So we just ignore it.
        if (realKey == Key.F10
            && (Keyboard.IsKeyDown(Key.LeftShift)
                || Keyboard.IsKeyDown(Key.RightShift)))
        {
            return false;
        }

        var isShowOrHideKey = this.KeyTipKeys.Any(x => x == realKey);

        if (isShowOrHideKey == false)
        {
            return false;
        }

        var blacklistedModifierKeys = modifierKeys.Except(this.KeyTipKeys);
        var blacklistedKeyPressed = blacklistedModifierKeys.Any(Keyboard.IsKeyDown);
        return blacklistedKeyPressed == false;
    }

    private void ClearUserInput()
    {
        this.currentUserInput = string.Empty;
    }

    private void ClosePopups()
    {
        PopupService.RaiseDismissPopupEvent(Keyboard.FocusedElement, DismissPopupMode.Always, DismissPopupReason.ShowingKeyTips);
    }

    private void RestoreFocus()
    {
        this.backUpFocusedControl?.Focus();
        this.backUpFocusedControl = null;
    }

    private void OnAdornerChainTerminated(object? sender, KeyTipPressedResult e)
    {
        if (this.activeAdornerChain is not null)
        {
            this.activeAdornerChain.Terminated -= this.OnAdornerChainTerminated;
        }

        this.activeAdornerChain = null;
        this.ClearUserInput();

        if (e.PressedElementOpenedPopup == false)
        {
            this.ClosePopups();
        }

        if (e.PressedElementAquiredFocus == false)
        {
            this.RestoreFocus();
        }
    }

    private void OnDelayedShow(object? sender, EventArgs e)
    {
        if (this.activeAdornerChain is null)
        {
            this.Show();
        }

        this.timer.Stop();
    }

    private void ShowImmediatly()
    {
        this.Show();
    }

    private void ShowDelayed()
    {
        this.Terminate();

        this.timer.Start();
    }

    private void Terminate()
    {
        this.activeAdornerChain?.Terminate(KeyTipPressedResult.Empty);
    }

    private void Show()
    {
        this.timer.Stop();

        // Check whether the window is
        // - still present (prevents exceptions when window is closed by system commands)
        // - still active (prevents keytips showing during Alt-Tab'ing)
        if (this.window is null
            || this.window.IsActive == false)
        {
            this.RestoreFocus();
            return;
        }

        // Special behavior for backstage, application menu and start screen.
        // If one of those is open we have to forward key tips directly to them.
        var keyTipsTarget = this.GetStartScreen()
                            ?? this.GetBackstage()
                            ?? this.GetApplicationMenu()
                            ?? this.ribbon;

        if (keyTipsTarget is null)
        {
            return;
        }

        this.ClosePopups();

        this.backUpFocusedControl = null;

        // If focus is inside the Ribbon already we don't want to jump around after finishing with KeyTips
        if (UIHelper.GetParent<Ribbon>(Keyboard.FocusedElement as DependencyObject) is null)
        {
            this.backUpFocusedControl = FocusWrapper.GetWrapperForCurrentFocus();
        }

        if (keyTipsTarget is Ribbon && this.ribbon.TabControl != null)
        {
            // Focus ribbon
            int selectedIndex = Math.Max(this.ribbon.TabControl.SelectedIndex, 0);
            (this.ribbon.TabControl.ItemContainerGenerator.ContainerFromIndex(selectedIndex) as UIElement)?.Focus();
        }

        this.ClearUserInput();

        if (this.activeAdornerChain is not null)
        {
            this.activeAdornerChain.Terminated -= this.OnAdornerChainTerminated;
        }

        // to mimik the Office behavior we always attach the adorner to the ribbon
        this.activeAdornerChain = new KeyTipAdorner(this.ribbon, this.ribbon, null);
        this.activeAdornerChain.Terminated += this.OnAdornerChainTerminated;
        this.activeAdornerChain.Attach();

        // continuation of Office mimik: if the real target wasn't the ribbon we immediately forward to the target control
        if (keyTipsTarget is not Ribbon)
        {
            this.activeAdornerChain.Forward(string.Empty, keyTipsTarget, false);
        }
    }

    private FrameworkElement? GetBackstage()
    {
        if (this.ribbon.Menu is null)
        {
            return null;
        }

        var control = this.ribbon.Menu as Backstage ?? UIHelper.FindImmediateVisualChild<Backstage>(this.ribbon.Menu, IsVisible);

        if (control is null)
        {
            return null;
        }

        return control.IsOpen
            ? control
            : null;
    }

    private FrameworkElement? GetApplicationMenu()
    {
        if (this.ribbon.Menu is null)
        {
            return null;
        }

        var control = this.ribbon.Menu as ApplicationMenu ?? UIHelper.FindImmediateVisualChild<ApplicationMenu>(this.ribbon.Menu, IsVisible);

        if (control is null)
        {
            return null;
        }

        return control.IsDropDownOpen
            ? control
            : null;
    }

    private FrameworkElement? GetStartScreen()
    {
        var control = this.ribbon.StartScreen;

        if (control is null)
        {
            return null;
        }

        return control.IsOpen
            ? control
            : null;
    }

    private static bool IsVisible(FrameworkElement obj)
    {
        return obj.Visibility == Visibility.Visible;
    }
}